Skip to content

Vue 响应式与双向绑定原理 ​

作者:青见春山
发表于:2026-07-29
字数统计:6000 字
预计阅读21分钟

涵盖 Vue2 / Vue3 响应式原理、双向绑定机制、defineProperty 实现双向绑定 demo、以及 Vue3 用 Proxy 替代 defineProperty 的原因。原文件中的三处重复内容(响应式原理、双向绑定、为什么用 Proxy)合并到本文。

一、Vue2 响应式原理 ​

Vue2 通过 Object.defineProperty 对 data 中的属性进行劫持,在属性的 getter 中进行依赖收集,在 setter 中进行依赖通知。每个属性都会对应一个 Dep 依赖管理器,用于存储依赖该数据的 watcher。当组件渲染时会生成 watcher,并在访问数据时触发 getter,将 watcher 收集到 Dep 中。当数据发生变化时触发 setter,Dep 会通知所有 watcher 执行更新,从而重新渲染视图。

二、Vue3 响应式原理 ​

Vue3 使用 Proxy 对整个对象进行代理,通过拦截对象的 get、set、deleteProperty 等操作实现响应式。在 get 时进行依赖收集,在 set 时触发依赖更新,从而实现数据变化自动更新视图。相比 Vue2,Proxy 可以监听对象新增属性、删除属性以及数组变化,并且不需要递归遍历对象,性能更好。

三、双向数据绑定 ​

标准面试回答(1 分钟) ​

Vue 的双向数据绑定是"数据劫持 + 发布订阅模式"的组合。核心机制分两层:数据层通过响应式系统监听数据变化,视图层通过指令(v-model)监听 DOM 事件,两者配合实现数据变化驱动视图、视图变化回写数据的双向同步。

Vue 2 版本:核心是 Object.defineProperty,在组件初始化时递归遍历 data 中的所有属性,用 defineProperty 劫持 getter 和 setter。每个属性对应一个 Dep 依赖收集器,getter 里收集用到这个数据的 Watcher(观察者),setter 里触发 Dep.notify,通知所有 Watcher 执行更新。视图这边,v-model 是语法糖,本质上是绑定了 value 属性和 input 事件——输入时触发事件更新 data,data 变化时通过响应式系统重新渲染视图。

Vue 3 版本:用 Proxy 替代了 defineProperty。Proxy 可以直接代理整个对象,不需要递归遍历,能监听到数组索引修改、对象新增属性等 defineProperty 做不到的操作,性能更好,也解决了 Vue 2 里 $set 这种 API 的痛点。

整体流程:初始化时建立响应式 → 编译模板时建立依赖关系 → 数据变化时派发更新 → 重新渲染 → 通过 v-model 监听用户输入回写数据。

核心角色拆解 ​

  • Observer(劫持):负责把 data 变成响应式
  • Dep(依赖收集):每个响应式属性对应一个,存储依赖它的 watcher
  • Watcher(观察者):订阅数据变化,触发更新(渲染 watcher、computed watcher、用户 watcher)
  • Compiler(编译):解析模板指令,把数据和 DOM 绑定

订阅者补充 ​

订阅者是 Vue 中的一个概念,它是一个用于管理更新视图的对象。当数据发生变化时,Vue 会通知所有的订阅者进行更新。在 Vue 中,每一个挂载到视图上的组件,或者每一个 watcher,都可以被看成一个订阅者,他们订阅了某一个数据的变化,并等待数据发生变化时进行更新。订阅者是 Vue 实现双向数据绑定的关键组成部分,管理着数据和视图之间的关系,保证了数据的变化能够及时反应到视图上。

20 秒极简版 ​

Vue 双向绑定是数据劫持加发布订阅模式。Vue 2 用 defineProperty 劫持 getter/setter,Vue 3 用 Proxy。v-model 本质是 value 绑定加 input 事件的语法糖,数据变化驱动视图,视图交互回写数据。

四、用 defineProperty 实现简单双向绑定 ​

最经典的双向绑定 demo,使用 Object.defineProperty() 实现:

JavaScript
// 1. 创建一个数据对象
var Person = {}
var name = null

// 2. 使用 Object.defineProperty 劫持 name 属性的读写
Object.defineProperty(Person, 'name', {
    get: function () {
        return name  // 读取时返回私有变量
    },
    set: function (newV) {
        name = newV  // 设置时更新私有变量
        // 注意:这里可以触发视图更新,但代码中没有体现
    }
})

// 3. 监听输入框的 input 事件
let p = document.getElementById('ppp')
let ipt = document.getElementById('ipt')

ipt.addEventListener('input', function (e) {
    Person.name = e.target.value  // 视图变化 → 更新数据
    p.innerText = Person.name      // 数据变化 → 更新视图(手动触发)
})

核心机制:双向绑定的两个方向 ​

  1. View → Model(视图到数据)

    JavaScript
    ipt.addEventListener('input', function (e) {
        Person.name = e.target.value  // 输入框变化,更新数据
    })
  2. Model → View(数据到视图)

    JavaScript
    set: function (newV) {
        name = newV
        // 当 Person.name 被赋值时,会触发 setter
        // 理想情况下,这里应该自动更新视图
        // 但当前代码需要手动更新
    }

原代码的问题与改进 ​

原代码并未真正实现自动双向绑定,因为 setter 中缺少自动更新视图的代码:

JavaScript
// 改进版本:setter 中自动更新视图
var Person = {}
var name = null

Object.defineProperty(Person, 'name', {
    get: function () {
        return name
    },
    set: function (newV) {
        name = newV
        // 自动更新视图
        document.getElementById('ppp').innerText = newV
    }
})

let ipt = document.getElementById('ipt')
ipt.addEventListener('input', function (e) {
    Person.name = e.target.value  // 这里触发 setter,会自动更新 p 元素
})

完整手写实现 ​

JavaScript
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log(`读取 ${key}: ${val}`)
            return val
        },
        set(newVal) {
            if (newVal !== val) {
                console.log(`更新 ${key}: ${val} -> ${newVal}`)
                val = newVal
                // 触发视图更新
                updateView()
            }
        }
    })
}

// 使用
let data = {}
defineReactive(data, 'name', '张三')

// 绑定视图
let input = document.getElementById('input')
let span = document.getElementById('span')

input.addEventListener('input', (e) => {
    data.name = e.target.value  // 触发 setter
})

function updateView() {
    span.innerText = data.name  // 从 getter 获取最新值
}

五、defineProperty 的三大缺陷 ​

  1. 无法监听数组索引修改和 length 变化:所以 Vue 2 对数组重写了 7 个变异方法(push、pop、shift、unshift、splice、sort、reverse)来 hack 实现
    JavaScript
    arr.push(1)  // 无法触发 setter
  2. 无法监听对象新增或删除属性:所以提供了 $set 和 $delete API
    JavaScript
    delete Person.name  // 无法监听
    Person.age = 18     // 无法监听
  3. 初始化时需要递归遍历所有属性:性能开销大,嵌套对象越深开销越大

六、追问:高分手答 ​

追问 1:v-model 在自定义组件上怎么工作? ​

在 Vue 3 中,自定义组件的 v-model 默认相当于绑定了 modelValue 属性,并监听 update:modelValue 事件。组件内部需要接收 props.modelValue,然后通过 emit('update:modelValue', newValue) 来触发更新。

如果需要绑定多个双向绑定的值,Vue 3 支持给 v-model 传参数,比如 v-model:title="pageTitle",组件内部就接收 title 属性,触发 update:title 事件。

Vue 2 稍有不同,需要通过 model 选项配置 prop 和 event,但原理一样。实际开发中封装表单组件、弹窗组件时经常用到。

追问 2:Watcher 和 Dep 的关系? ​

Dep 是每个响应式属性的"依赖收集器",相当于一个订阅中心。Watcher 是"观察者",比如一个组件对应一个渲染 Watcher,计算属性对应一个计算 Watcher。

关系是:当响应式属性被读取时(getter),会把当前的 Watcher 收集到它的 Dep 里;当属性被修改时(setter),Dep 会通知所有订阅的 Watcher 执行更新。

一对多的场景很常见——一个响应式属性可能被多个地方用到。比如 this.name 在模板里出现了三次,那这三个地方都属于同一个渲染 Watcher,所以 Dep 里只收集一个 Watcher。更典型的例子是:this.user.name 这个属性,可能被组件 A 的渲染 Watcher、组件 B 的渲染 Watcher、还有某个计算属性 Watcher 同时依赖,那这个属性的 Dep 里就维护了三个 Watcher,数据变化时三个地方都会更新。

追问 3:defineProperty 的缺陷和 Proxy 的改进? ​

主要有三个缺陷。第一,无法监听数组索引修改和 length 变化,所以 Vue 2 对数组重写了 7 个变异方法(push、pop 等)来 hack 实现。第二,无法监听对象新增或删除属性,所以提供了 $set 和 $delete API。第三,初始化时需要递归遍历所有属性,性能开销大,嵌套对象越深开销越大。

Vue 3 的 Proxy 完美解决了这些:Proxy 代理整个对象,数组索引修改、length 变化、对象新增属性都能自动监听到;而且 Proxy 是懒代理,访问到嵌套对象时才递归代理,初始性能更好。另外 Proxy 支持 13 种拦截操作,比 defineProperty 灵活很多。

七、为什么 Vue3 用 Proxy 替代 defineProperty ​

核心结论 ​

Vue 3 用 Proxy 替换 Object.defineProperty,核心原因是为了解决 defineProperty 在响应式系统中的三大先天缺陷:无法监听数组索引和 length 变化、无法监听对象属性的新增和删除、初始化时需要递归遍历所有属性导致性能问题。Proxy 通过代理整个对象,从根本上解决了这些问题,同时带来了更好的性能和更简洁的 API 设计。

监听能力对比 ​

defineProperty 只能劫持对象的已有属性,对数组的索引修改(如 arr[0] = newValue)和 length 变化无法监听,所以 Vue 2 需要 hack 数组的 7 个变异方法(push、pop 等)来模拟响应式。对对象新增属性(obj.newProp = value)也监听不到,必须用 $set。Proxy 代理整个对象,所有操作(属性读取、赋值、删除、数组方法)都能拦截,不需要任何 hack。

初始化性能 ​

defineProperty 需要在初始化时递归遍历对象的每一层属性,嵌套越深性能开销越大。Proxy 是懒代理——只在访问到某个嵌套对象时才进行代理,初始化性能更好。

语法简洁性 ​

defineProperty 需要为每个属性单独定义 getter/setter,代码繁琐;Proxy 通过一个代理对象统一拦截所有操作,API 更清晰,还支持 13 种拦截操作(get、set、deleteProperty、has 等),扩展性更强。

数组的原生支持 ​

Proxy 可以完美监听数组的所有操作,包括索引赋值、length 变化、push/pop 等原生方法,Vue 3 不再需要重写数组方法。

追问 1:Proxy 的懒代理具体是怎么实现的? ​

Vue 3 的响应式系统中,reactive 函数创建代理时,只会代理传入的根对象,不会递归代理嵌套对象。

当访问嵌套对象时(如 state.user.profile.name),会在 get 拦截器中判断:如果读取的值是对象,则对该对象调用 reactive 进行代理,然后返回代理后的对象。这就是"懒代理"——只有在真正访问到嵌套对象时才进行代理。

对比 Vue 2 的 defineProperty:初始化 data 时,会递归遍历整个对象树,给每一层每个属性都加上 getter/setter。如果对象结构很深,或者有大量冗余数据,初始化性能开销很大。Vue 3 的懒代理在大部分场景下性能更好,尤其适合有深层嵌套但实际只访问部分路径的数据结构。

追问 2:Proxy 能监听所有数组方法吗? ​

能。Proxy 代理的是数组对象本身,对数组的任何属性访问或修改都会被拦截。

  • arr[100] = 1:触发 set 拦截器,可以检测到索引赋值
  • arr.length = 0:触发 set 拦截器,可以检测到 length 变化
  • arr.push(1):会触发多个操作,先触发 set 设置新索引,再触发 set 修改 length,都能被拦截

而 Vue 2 的 defineProperty 无法监听直接通过索引修改数组元素,也无法监听 length 变化,所以只能通过重写 push、pop、shift、unshift、splice、sort、reverse 这 7 个方法,在调用这些方法时手动触发更新。Proxy 完全不需要这些 hack,代码更干净,也消除了"为什么直接改数组索引不更新"这类新手困惑。

追问 3:Proxy 有什么缺点? ​

Proxy 主要有两个缺点:

第一,兼容性问题。Proxy 是 ES6 特性,无法被 polyfill 到 IE11 及更低版本浏览器。Vue 2 发布于 2016 年,当时还需要兼容 IE 浏览器,所以选择 defineProperty。Vue 3 放弃了对 IE 的支持,所以可以放心使用 Proxy。

第二,代理对象不能直接比较。reactive 返回的是代理对象,和原始对象 === 比较会返回 false。这在某些场景下需要注意,比如需要判断某个对象是否已经存在于数组中。Vue 3 提供了 toRaw API 来获取原始对象。

另外,Proxy 的性能在极个别场景下可能略低于 defineProperty(因为每次操作都要经过代理层),但整体收益远大于损耗。Vue 3 通过编译优化和响应式系统的精细设计,实际性能远超 Vue 2。

八、Vue 2 与 Vue 3 的整体差异 ​

核心升级 ​

  1. 响应式系统:defineProperty → Proxy
  2. API 风格:选项式 API → 组合式 API(保留选项式)
  3. 性能优化:重写虚拟 DOM,静态提升、PatchFlags、Block Tree
  4. TypeScript:原生 TS 支持,类型推断更准确
  5. 内置组件:新增 Teleport、Fragments、Suspense
  6. 生命周期:beforeDestroy/destroyed → beforeUnmount/unmounted
  7. 工程化:官方推荐 Vite

易错点 ​

  • 只说"Vue 3 性能更好"说不清为什么 → 要提 Proxy、静态提升、PatchFlags
  • 组合式 API 和 React Hooks 混淆 → Hooks 每次渲染重新执行,组合式 API 只执行一次
  • 忽略 Vue 3 的多根节点支持(Fragment)
  • 生命周期钩子名称说错(beforeUnmount / unmounted)
  • 忘记提及 Vite

九、关联文档 ​

十、相关面试题 ​

问题考察点
什么是双向数据绑定?理解 MVVM 架构
Object.defineProperty 和 Proxy 的区别版本差异、优缺点
如何实现数据驱动视图?观察者模式、发布订阅
Vue 的响应式原理源码理解
手写一个简单的双向绑定综合能力